Skip to content

feat: add message thread read receipts#956

Merged
AchoArnold merged 24 commits into
mainfrom
feat/read-receipts
Jul 19, 2026
Merged

feat: add message thread read receipts#956
AchoArnold merged 24 commits into
mainfrom
feat/read-receipts

Conversation

@AchoArnold

Copy link
Copy Markdown
Member

Summary

  • add backward-compatible message-thread read state with atomic partial persistence
  • mark inbound SMS and missed-call threads unread while preserving open-thread reads across realtime races
  • automatically mark opened threads read and highlight unread threads in the web UI
  • add Docker-backed read-receipts integration coverage in tests/

Validation

  • API targeted tests and handler compile check pass
  • web lint and static generation pass
  • integration package compiles and passes vet
  • full branch code review completed, including a GORM explicit-false persistence regression fix

Note

  • Docker is unavailable on the development host, so the new runtime integration test was not executed locally; CI is expected to run the Docker stack.

AchoArnold and others added 16 commits July 18, 2026 09:55
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
@codacy-production

codacy-production Bot commented Jul 18, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 145 complexity · 12 duplication

Metric Results
Complexity 145
Duplication 12

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds first-class read-receipt support for message threads: new is_read / last_read_at fields with backward-compatible database defaults, atomic partial persistence via GORM transactions with a GORM zero-value bug fix, and a watermark-based race guard that prevents late inbound events from flipping a thread back to unread after the user has already opened it.

  • Backend: MessageThread entity gains is_read (default true) and last_read_at; Update is split into UpdateActivity (last-message fields) and UpdateStatus (archive/read), each with targeted SQL; OnMessageCallMissed is wired into both the thread listener and WebSocket listener.
  • Frontend: The threads store gains markThreadRead with optimistic local replacement and error-recovery reload; MessageThread.vue highlights unread threads with bold text and a tinted background; opening a thread or receiving a Pusher event while the thread is open calls markThreadRead to keep the server state in sync.

Confidence Score: 4/5

Safe to merge. The watermark logic, GORM zero-value workaround, and split-update architecture are all sound. The two findings are edge-case efficiency and rare-race concerns that do not affect data correctness.

The backend and frontend changes work correctly in the normal path. Two non-blocking issues were found: concurrent mark-read requests when a Pusher event fires while thread.is_read is already false in the store (cosmetic extra API call), and a narrow window in UpdateStatus where a concurrent thread deletion between the UPDATE and the reload SELECT could surface as a false 404. Neither affects stored data integrity or typical user flows.

web/app/pages/threads/[id]/index.vue (duplicate concurrent mark-read calls) and api/pkg/services/message_thread_service.go (post-update reload TOCTOU).

Important Files Changed

Filename Overview
api/pkg/repositories/gorm_message_thread_repository.go Core persistence layer refactored: Update split into UpdateActivity and UpdateStatus, each targeting only their own columns. Store gains a GORM zero-value workaround transaction that issues an explicit UpdateColumn("is_read", false) after a fresh INSERT. UpdateActivity uses a watermark (WHERE last_read_at < event_timestamp) to make the unread flip idempotent.
api/pkg/services/message_thread_service.go UpdateStatus now calls repository.UpdateStatus first, then reloads for the response — creates a narrow TOCTOU window where a concurrent deletion between the two DB calls causes the handler to return 404 even though the update was committed.
api/pkg/handlers/message_thread_handler.go Swagger annotation updated (returns MessageThreadResponse), 404 handling added for missing threads. Error check order (ErrCodeNotFound before generic err) is correct.
api/pkg/listeners/message_thread_listener.go OnMessageCallMissed handler added and registered; OnMessagePhoneReceived now passes MarksUnread=true and EventTimestamp for the watermark.
web/app/pages/threads/[id]/index.vue markCurrentThreadRead(force) added; both inbound Pusher handlers call force=true followed by loadMessages() which also calls markCurrentThreadRead(). Can produce two concurrent mark-read API calls when thread.is_read is false in the store.
web/app/stores/threads.ts markThreadRead added with optimistic local replacement via replaceThread; falls back to full loadThreads on API error.
api/pkg/entities/message_thread.go IsRead (default:true) and LastReadAt (default:CURRENT_TIMESTAMP) added with backward-compatible DB defaults for GORM AutoMigrate.
tests/read_receipts_test.go New Docker-backed integration test covers the full inbound to unread to mark-read to missed-call to unread to outbound-preserves-unread cycle.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Phone as Mobile Phone
    participant API as API Server
    participant DB as PostgreSQL
    participant Pusher as Pusher/WebSocket
    participant Web as Web UI (Vue)

    Phone->>API: POST /v1/messages/receive
    API->>DB: LoadByOwnerContact (find thread)
    API->>DB: UpdateActivity (order_timestamp, last_message_id, status)
    Note over API,DB: Watermark check: WHERE last_read_at < event.Time()
    DB-->>API: "UPDATE is_read=false (if watermark passes)"
    API->>Pusher: Trigger message.phone.received
    Pusher-->>Web: Push event
    Web->>API: "PUT /v1/message-threads/{id} {is_read:true} force=true"
    API->>DB: "UpdateStatus is_read=true last_read_at=now"
    API->>DB: Load thread
    API-->>Web: Updated thread (is_read:true)
    Web-->>Web: replaceThread() update store
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Phone as Mobile Phone
    participant API as API Server
    participant DB as PostgreSQL
    participant Pusher as Pusher/WebSocket
    participant Web as Web UI (Vue)

    Phone->>API: POST /v1/messages/receive
    API->>DB: LoadByOwnerContact (find thread)
    API->>DB: UpdateActivity (order_timestamp, last_message_id, status)
    Note over API,DB: Watermark check: WHERE last_read_at < event.Time()
    DB-->>API: "UPDATE is_read=false (if watermark passes)"
    API->>Pusher: Trigger message.phone.received
    Pusher-->>Web: Push event
    Web->>API: "PUT /v1/message-threads/{id} {is_read:true} force=true"
    API->>DB: "UpdateStatus is_read=true last_read_at=now"
    API->>DB: Load thread
    API-->>Web: Updated thread (is_read:true)
    Web-->>Web: replaceThread() update store
Loading

Reviews (1): Last reviewed commit: "style(api): apply gofumpt to handler tes..." | Re-trigger Greptile

Comment thread web/app/pages/threads/[id]/index.vue
Comment thread api/pkg/services/message_thread_service.go Outdated
AchoArnold and others added 4 commits July 18, 2026 12:11
Integrate current origin/main (thread archive-view preservation #952,
unarchive-on-receive #954, webhook email payload formatting #955) with the
read-receipts feature set, preserving both.

Semantic resolutions:
- UpdateThread now resolves the per-phone unarchive decision and the inbound
  unread watermark in a single atomic repository.UpdateActivity call. Added an
  Unarchive flag to MessageThreadActivityUpdate so is_archived=false is applied
  in the same transaction as the read-state/last-message updates.
- Missed calls and inbound SMS both use MessageStatusReceived + MarksUnread, so
  they share the same unarchive + mark-unread rules.
- Combined the message thread service test suites (read-receipt stubs plus
  shouldCheckUnarchive coverage) and updated the service/handler test helpers to
  the 5-arg NewMessageThreadService signature (adds phoneRepository).
- Merged tests/README.md coverage entries for both E2E suites.
- Regenerated Swagger and web/shared/types/api.ts so both main's new fields and
  read-receipt fields are present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Preserve read-receipt partial updates while incorporating webhook hardening and repository-wide Go vet fixes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Avoid duplicate realtime read updates and the post-write reload race.

Retry asynchronous phone-key binding in integration tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements message-thread read receipts across the Go API, Nuxt web UI, and Docker-backed tests/ integration suite. It adds a persisted per-thread is_read state with concurrency-safe partial updates, marks inbound SMS/missed calls unread, auto-marks opened threads read, and visually highlights unread threads in the thread list.

Changes:

  • API: add is_read (plus internal last_read_at) and extend the existing thread update endpoint to support optional is_archived / is_read partial updates, with listener-driven unread transitions for inbound SMS and missed calls.
  • Web: regenerate API types, add a Pinia markThreadRead() workflow, auto-mark the open thread as read, and apply unread styling in the thread list.
  • Tests/docs: add an end-to-end read-receipts integration test and document the design + implementation plan.

Reviewed changes

Copilot reviewed 29 out of 30 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
web/shared/types/api.ts Regenerated TS API contract to include is_read and optional thread update fields.
web/app/stores/threads.ts Adds replaceThread() + markThreadRead() to persist and reflect read state in local store.
web/app/pages/threads/[id]/index.vue Automatically marks the opened thread read; forces read update after inbound realtime events.
web/app/components/MessageThread.vue Applies unread highlighting (bold + subtle primary-tint background).
tests/README.md Documents read-receipts E2E coverage in the integration checklist.
tests/read_receipts_test.go Adds Docker-stack E2E test for unread/read transitions and outbound preservation.
docs/superpowers/specs/2026-07-18-read-receipts-design.md Captures the approved read-receipts design and concurrency rules.
docs/superpowers/plans/2026-07-18-read-receipts.md Detailed implementation plan used to drive the feature work.
api/pkg/entities/message_thread.go Adds persisted is_read and internal last_read_at fields with backward-compatible defaults.
api/pkg/entities/message_thread_test.go Verifies read-field defaults via reflection.
api/pkg/requests/message_thread_update_request.go Makes is_archived optional and adds optional is_read to the update request.
api/pkg/requests/message_thread_update_request_test.go Ensures request-to-service params preserve optional field presence.
api/pkg/validators/message_thread_handler_validator.go Requires at least one of is_archived or is_read on update payloads.
api/pkg/validators/message_thread_handler_validator_test.go Tests empty payload rejection + read-only update acceptance.
api/pkg/responses/message_thead_responses.go Adds single-thread Swagger response wrapper.
api/pkg/repositories/message_thread_repository.go Introduces field-scoped update parameter types and new repository methods for atomic partial updates.
api/pkg/repositories/gorm_message_thread_repository.go Implements atomic partial updates, conditional unread transition, and explicit-false persistence workaround for is_read.
api/pkg/repositories/gorm_message_thread_repository_test.go Adds unit tests for update-map ownership and explicit unread persistence on create.
api/pkg/services/message_thread_service.go Orchestrates new repository update flows; adds unread watermark parameters; supports partial status updates.
api/pkg/services/message_thread_service_test.go Validates service-level unread/read rules and status-update behavior.
api/pkg/listeners/message_thread_listener.go Marks inbound SMS and missed calls unread (and creates missed-call thread activity).
api/pkg/listeners/message_thread_listener_test.go Tests listener sets MarksUnread and propagates event timestamps.
api/pkg/listeners/read_receipts_test_helpers_test.go Provides listener test doubles for the new read-receipts behavior.
api/pkg/listeners/websocket_listener.go Publishes message.call.missed via Pusher.
api/pkg/listeners/websocket_listener_test.go Ensures missed-call websocket route is registered.
api/pkg/handlers/message_thread_handler.go Updates Swagger response type and maps not-found update attempts to 404.
api/pkg/handlers/message_thread_handler_test.go Tests 404 mapping for missing thread updates.
api/docs/docs.go Regenerated Swagger output for the new thread contract and responses.
api/docs/swagger.json Regenerated Swagger output for the new thread contract and responses.
api/docs/swagger.yaml Regenerated Swagger output for the new thread contract and responses.
Files not reviewed (1)
  • api/docs/docs.go: Generated file

Comment thread api/pkg/repositories/gorm_message_thread_repository.go
Comment thread api/pkg/repositories/gorm_message_thread_repository.go Outdated
Comment thread api/pkg/services/message_thread_service.go Outdated
Comment thread api/pkg/services/message_thread_service.go Outdated
Use PostgreSQL RETURNING so concurrent activity cannot make status update responses stale.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • api/docs/docs.go: Generated file

Comment thread api/pkg/listeners/message_thread_listener.go
Comment thread api/pkg/services/message_thread_service.go Outdated
Comment thread api/pkg/repositories/gorm_message_thread_repository.go Outdated
Fold the unread watermark into the activity update so inbound events use one database query.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • api/docs/docs.go: Generated file

@AchoArnold
AchoArnold merged commit c36e30f into main Jul 19, 2026
7 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants